모던 리액트 5장
모던 리액트 공부 기록
2024-01-30
애플리케이션 전체적으로 관리해야 할 상태가 있을 때, 이런 상태 변화가 일어남에 따라 즉각적으로 모든 요소들이 변경되어 애플리케이션이 찢어지는 현상을 어떻게 막을 수 있을까?
다른 웹 개발 환경과 마찬가지로, 리액트도 상태관리에 대한 필요성이 존재했다. 프레임워크를 지향하는 Angular와 다르게 리액트는 단순히 사용자 인터페이스를 만들기 위한 라이브러리일 뿐 , 그 이상의 기능은 제공하지 않고 있다. 따라서 상태를 관리하는 방법도 시간에 따라 많은 변화가 존재했다.
Flux 패턴
리액트에서는 전역 상태관리를 어떻게 했을까? 리덕스가 나타나기 전까지 리액트 애플리케이션에서 이름을 널리 알린 상태 관리 라이브러리는 없었다. Flux가 나올 당시 웹 애플리케이션이 비대해지고 상태도 많아짐에 따라 어디서 어떤 일이 일어나서 이 상태가 변경되었는지 등을 추적하는 것이 매우 어려운 상황이었다.
위 그림처럼 Model은 View를 변경할 수 있고, View는 Model을 변경할 수 있다. 코드가 적고 간단한 애플리케이션은 이런 패턴이 괜찮지만, 변경 시나리오가 많아지고 애플리케이션이 거대해질수록 관리가 어려워진다. 양방향이 아닌 단방향의 데이터 흐름을 변경하는 것이 Flux 패턴의 시작이다.
- 액션 : 어떤 작업을 처리할 액션과 , 액션 발생 시 함께 포함시킬 데이터를 의미한다. 액션 타입과 데이터를 정의해 디스패쳐로 보낸다.
- 디스패쳐 : 액션을 스토어로 보내는 역할을 한다. 액션이 정의한 타입과 데이터를 모두 스토어에 보낸다.
- 스토어 : 실제 상태에 따른 값과 , 상태를 변경할 수 있는 메서드를 갖고 있다. 액션의 타입에 따라 어떻게 이를 변경할지 정의되어 있다.
- 뷰 : 스토어에서 만들어진 데이터를 가져와 화면을 렌더링하는 역할을 한다. 뷰에서 액션을 호출한다.
이러한 흐름속에 리덕스가 등장한다. 리덕스는 최초에는 이 Flux 구조를 구현하기 위해 만들어진 라이브러리 중 하나이다. 리덕스는 하나의 상태 객체를 스토어에 넣어두고, 이 객체를 업데이트 하는 작업을 디스패치해 업데이트를 수행한다. 이 작업은 reducer함수로 발생시킬 수 있는데, 이 함수의 실행은 웹 애플리케이션 상태에 대해 완전히 새로운 복사본을 반환한 다음, 애플리케이션에 새로 만들어진 상태를 전파한다.
이런 리덕스의 등장은 props drilling 문제를 해결할 수 있었고 스토어에 바로 접근할 수 있게 되었다. (store.getState())
Props를 간편하게 넘겨주기 위해 16.3 버전에서 Context API 출시했으나 다만 아래와 같은 문제점이 있었다. 상위 컴포넌트가 렌더링 되면 shouldComponentUpdate가 항상 true를 반환하여 불필요한 렌더링이 일어난다. context를 인수로 받기 때문에 컴포넌트와 결합도가 높다. 렌더링을 막아주는 기능이 없다.
리액트 훅으로 시작하는 상태 관리
오랜 시간동안 리액트 애플리케이션의 상태 관리를 위해 리덕스에 의존했다. 그러나 현재는 새로운 Context API, useReducer, useState의 등장으로 컴포넌트에 결처셔 재사용하거나 컴포넌트 내부에 걸쳐서 상태를 관리할 수 있는 방법들이 점차 많이 등장하기 시작했고, 리덕스 외의 다른 라이브러를 선택하는 경우도 많아지고 있다.
가장 기본적으로 useState와 useReducer를 사용할 수 있다.
useState와 useReducer가 상태 관리의 모든 필요성과 문제를 해결해주진 않는다. useState나 useReducer를 기반으로 하는 커스텀 훅의 한계는 명확하다. 훅을 쓸 때마다 컴포넌트가 초기화되므로 컴포넌트에 따라 다른 상태를 가질 수 밖에 없다. 이렇게 useState나 useReducer를 기반으로 한 상태를 지역상태라고 한다. 지역상태는 컴포넌트 내에서만 유효하다는 한계가 있다.
함수 외부에서 어떤 상태를 참조하고 이를 통해 렌더링까지 자연스럽게 일어나려면 다음의 조건이 필요하다.
- 컴포넌트 외부에 상태를 두고 여러 컴포넌트가 동시에 접근해 사용할 수 있다.
- 이 외부에 있는 상태를 사용하는 컴포넌트는 상태의 변화를 알아내야 하고, 상태가 변경될 때마다 리렌더링이 일어나서 항상 최신 값을 바라봐야 한다. 이 상태 감지는 해당 상태를 참조하는 모든 컴포넌트에서 필요하다.
- 상태가 객체인 경우, 객체에 내가 감지하고 있지 않은 값이 바뀌더라도 리렌더링이 발생해서는 안된다.
위 조건을 충족하는 store를 만들어보자! store의 변경이 있을 때마다 변경이 되었음을 알리는 콜백함수를 실행하고, 이 콜백함수를 등록하는 subscribe 함수가 필요하다.
이제 createStore로 만들어진 store의 값을 참조하고 이 값의 변화에 따라 컴포넌트를 렌더링하는 커스텀 훅이 필요하다.
- 훅의 인수로 사용할 store를 받는다.
- 이 스토어의 값을 초기값으로 갖는 useState를 만든다. 이제 이 useState가 컴포넌트의 렌더링을 유도한다.
- useEffect로 store의 현재 값을 가져와 setState를 수행하는 함수를 store의 subscribe에 등록해 두었다.
- createStore 내부에서 값이 바뀔 때마다 subscribe에 등록된 콜백을 실행하므로, store의 값이 바뀔 때마다 state가 바뀌는 것을 보장한다.
- 클린업 함수로 unsubscribe를 등록해둔다.
그러나 앞서 useStore에서 객체 타입의 값인 경우 스토어의 객체 중 하나의 프로퍼티라도 바뀐다면 리렌더링이 다시 일어날 것이다.
두번째 인수로 selector의 함수를 받는다. useState는 값이 변경되지 않으면 렌더링을 수행하지 않으므로 store의 값이 변경되어도 selector(store.get())이 변경되지 않으면 렌더링을 수행하지 않는다.
위의 구조는 반드시 하나의 스토어만 갖게 된다. 만약 훅을 사용하는 서로 다른 스코프에서 여러 다른 데이터를 공유하고 싶으면 어떻게 해야 할까?
그러나 이방법은 스토어가 필요할 때마다 반복적으로 스토어를 만들어야 한다. 이 문제를 해결하기 위해 Context를 쓸 수 있다. Context를 사용해 해당 스토어를 하위 컴포넌트에 주입하면 된다.
Recoil
Recoil과 Jotai는 Context와 Provider, 훅을 기반으로 가능한 작은 상태를 효율적으로 관리하는 것에 초점을 맞추고 있다. 그리고 Zustand는 리덕스와 비슷하게 하나의 큰 스토어를 기반으로 상태를 관리하는 라이브러리이다. Recoil,Jotai와는 다르게 스토어의 상태가 변경되면 해당 상태를 구독하는 컴포넌트에 전파해 리렌더링을 알린다.
Recoil 팀에서는 리액트 18에서 제공되는 동시성, 서버 컴포넌트 등이 지원되기 전까지 1.0.0을 릴리스하지 않을 것이라고 밝힌 적이 있다. Recoil에서 핵심적인 RecoilRoot,atom,useRecoilValue,useRecoilState에 대해 알아보자
RecoilRoot은 Recoil을 사용하기 위해 애플리케이션의 최상단에 선언해야한다.
useStoreRef로 ancestorStoreRef의 존재를 확인하는데, 이는 상태값을 저장하는 스토어를 의미한다. 그리고 이 useStoreRef은 useContext로 AppContext를 가리키는 것을 볼 수 있다.
그리고 기본으로 넣어주는 defaultStore는 다음과 같은 구성으로 이루어져 있다.
스토어의 ID를 가져오는 getNextStoreId와 스토어의 값을 가져오는 getState,값을 수정하는 replaceState 등으로 이루어져 있다. 먼저 replaceState을 알아보자!
그럼 이 notifyComponents의 코드도 뜯어보자. 어떻게 되어 있길래 업데이트 된 상태를 하위 컴포넌트로 뿌릴 수 있는 걸까?
결국 RecoilRoot는 크게 3가지의 단계로 나뉜다.
- RecoilRoot의 AppContext에는 Recoil의 상태값들이 담긴다.
- 스토어의 상태값에 접근 할 수 있는 함수들로 상태의 읽기나 쓰기를 할 수 있다.
- 값의 변경이 있을 때 구독중인 모든 하위 컴포넌트에 값의 변화를 알린다.
atom은 상태를 나타내는 Recoil의 최소 단위이다.
atom은 key를 필수로 갖고 이 key는 다른 atom과 구별되는 역할을 한다. default는 이 atom의 초기값을 의미한다. atom의 값을 컴포넌트에서 읽고 쓰려면 useRecoilValue,useRecoilState 두 훅을 쓰면 된다. 먼저 useRecoilValue부터 알아보자!
useEffect를 통해 recoilValue가 변경될 때 forceUpdate를 호출해 렌더링을 강제로 일으킨다. forceUpdate는 말 그대로 렌더링을 강제로 일으키기 위한 함수이다.
useEffect를 통해 recoilValue가 변경되었을 때 forceUpdate를 사용해 렌더링을 강제로 일으키는 것을 볼 수 있다. useRecoilState는 useState와 유사하게 값을 가져오고 값을 변경할 수 있는 훅이다.
값을 가져오는 부분에는 useRecoilValue를 그대로 사용하고 값을 수정하는 부분은 useSetRecoilState함수를 쓰고 있다. 그럼 useSetRecoilState를 살펴보자.
지금까지 본 Recoil을 정리해보면 다음과 같다. 애플리케이션에서 RecoilRoot를 선언해 하나의 스토어를 만들고, atom이라는 고유한 상태 단위를 RecoilRoot에서 만든 스토어에 등록한다. 그리고 컴포넌트는 recoil의 훅을 통해 atom을 구독하고 상태가 변경되면 forceUpdate등을 통해 리렌더링을 하고 최신의 값을 가져온다.
Jotai
Jotai는 recoil과 비슷하게 리덕스와 같이 하나의 큰 상태를 애플리케이션에 내려주는 방식이 아닌, 작은 단위의 상태를 위로 전파할 수 있는 구조를 갖고 있다. 또 리액트 Context의 불필요한 리렌더링이 일어나는 문제를 해결하고자 설계되었으며 최적화를 거치지 않아도 리렌더링이 발생하지 않도록 설계되어 있다.
atom은 recoil과 마찬가지로 최소 단위의 상태를 의미한다. 또한 atom으로 파생된 상태도 만들 수 있다.
recoil과는 다르게 key를 넘기지 않아도 된다. 그리고 config을 반환하는데 이 config객체안에는 초기값의 init, 값을 읽는 read, 값을 쓰는 write프로퍼티가 존재한다. 그럼 atom은 어디서 저장되는 것일까? 결론은 recoil과는 다르게 store에 atom 객체 그 자체를 키로 활용해 값을 저장한다. 이 때 weakMap이라는 방식의 Map을 사용한다.
WeakMap은 JavaScript의 내장 객체로, 객체를 키로 사용할 수 있는 특별한 종류의 Map입니다. WeakMap의 키로 사용되는 객체는 가비지 콜렉션(GC)에 영향을 받지 않습니다. 즉, WeakMap이 키를 강하게 참조하지 않으므로, 키로 사용되는 객체가 메모리에서 제거되어야 할 때 해당 객체는 메모리에서 제거될 수 있습니다.
이러한 특성은 WeakMap이 '키'로 사용되는 객체가 여전히 존재하는 동안에만 '값'을 유지해야 하는 경우에 유용합니다. 만약 '키' 객체가 메모리에서 제거되면, '키'와 관련된 '값'도 자동으로 제거되므로 메모리 누수를 방지할 수 있습니다.
useAtom은 useState와 동일한 형태의 배열을 반환한다. 첫번째는 atom의 현재 값을 나타내는 결과이고 두번쨰는 useSetAtom훅을 반환하는데, 이 훅은 atom을 수정할 수 있는 기능을 제공한다.
Jotai는 결국 객체의 참조를 WeakMap에 보관하고 객체 자체가 변경되지 않는 한 별도의 키 없이도 객체의 참조를 유지하고 값을 관리할 수 있다.